Please enable JavaScript to view this website.

Skip to main content

Device Shadows: Firmware Guide

Prerequisites

The device must be fully provisioned before shadow operations will work:

  • The operational certificate must be active and attached to the device's IoT Thing
  • The iot_default_device_policy (attached automatically during Phase 2 provisioning) grants read/write access to $aws/things/{MPBID}/shadow/name/*

See Verifying a Provisioned Device for a test script that confirms shadow access end-to-end.

MQTT Topics

Replace {MPBID} with the device's uppercase MPBID and {shadowName} with one of the four shadow names (identity, status, config, location).

OperationTopicDirectionQoS
Update shadow state$aws/things/{MPBID}/shadow/name/{shadowName}/updatePublish1
Update accepted$aws/things/{MPBID}/shadow/name/{shadowName}/update/acceptedSubscribe1
Update rejected$aws/things/{MPBID}/shadow/name/{shadowName}/update/rejectedSubscribe1
Desired state changed$aws/things/{MPBID}/shadow/name/{shadowName}/update/deltaSubscribe1
Get current shadow$aws/things/{MPBID}/shadow/name/{shadowName}/getPublish1
Get accepted$aws/things/{MPBID}/shadow/name/{shadowName}/get/acceptedSubscribe1
Get rejected$aws/things/{MPBID}/shadow/name/{shadowName}/get/rejectedSubscribe1
Subscription budget

A fully-wired device subscribes to up to 5 topics per shadow (delta, update/accepted, update/rejected, get/accepted, get/rejected) × 4 shadows = up to 20 subscriptions on connect. Size your MQTT client's subscription table accordingly. If your stack has a hard limit, prioritize update/delta and get/accepted — those are required for correct operation.

Shadow Document Structure

Shadow payloads follow AWS IoT's standard shadow JSON format:

{
"state": {
"desired": {
"key": "value"
},
"reported": {
"key": "value"
}
}
}

When publishing, include only the section the device is writing. To report state without touching the desired state:

{
"state": {
"reported": {
"firmwareVersion": "1.2.3",
"status": "idle"
}
}
}

Delta Flow

The delta topic fires whenever the desired state differs from the reported state. The payload contains only the fields that differ — not the full shadow document. It also includes a version field that monotonically increments with each shadow update.

Firmware should:

  1. Subscribe to update/delta on connect for each shadow the device acts on
  2. On receipt, check the version field — ignore the delta if its version is older than the last version the device applied, to avoid acting on stale state after a reconnect
  3. Apply the desired changes locally
  4. Publish the new reported state back to update to confirm the change

To acknowledge a desired change received on the delta topic:

{
"state": {
"reported": {
"setting": "new-value"
}
}
}

AWS IoT will clear the delta for any field where desired and reported now match.

Shadow Field Reference

Draft schemas

These schemas are still being finalized. Treat field names and types as stable enough to implement against, but expect additions as hardware features are confirmed.

identity (device reports, read-only from cloud)

FieldTypeNotes
mpbidstring10-char hex device identifier
serial_numberstringManufacturer-assigned serial number
modelstringDevice model identifier (e.g. GATEWAY-1000)
hardware_revisionstringHardware revision (e.g. RevC)
manufacturerstringManufacturer name
manufacture_datestringManufacturing date (YYYY-MM-DD)
fw_ver_blestringBLE firmware version
fw_ver_cellularstringCellular modem firmware version
fw_ver_gnssstringGNSS firmware version
iccidstringSIM card ICCID
imsistringInternational Mobile Subscriber Identity
mac_addressstringDevice MAC address

status (device reports, read-only from cloud)

FieldTypeNotes
faultsstringActive fault/error codes (e.g. NO_FAULT)
last_scanintegerUTC epoch timestamp (seconds) of when the device last initiated a BLE scan
mqtt_disconnect_ctrintegerCount of MQTT broker disconnections
net_disconnect_ctrintegerCount of cellular network disconnections
power_healthstringBattery/power system status (e.g. GOOD)
power_sourcestringCurrent power source (e.g. battery, usb)
sessionstringCurrent MQTT or cellular session identifier
sim_disconnect_ctrintegerCount of SIM-related disconnections

config (cloud writes desired, device reads and reports)

This is the only shadow the cloud writes. The device reads desired on connect and on delta, applies the settings, then reports the applied values back.

Conventions: snake_case field names, durations in seconds with a _sec suffix, enums as literal strings.

Parse this shadow tolerantly

Firmware must read config defensively, applying what it understands and not hard-failing on the rest:

  • Ignore unknown fields. If a key appears that this build does not recognize, skip it and keep going. Do not reject the whole document.
  • Tolerate missing fields. Any field may be absent. Fall back to a compiled-in default so the device is fully operable from first boot without waiting on the cloud.
  • Fall back on out-of-range or wrong-type values. Clamp or default, then continue. Do not discard the entire config.

The fleet upgrades firmware asynchronously over long windows, so the schema on one side can run ahead of the other. Reading tolerantly lets both sides keep operating on the parts they understand. Use any CRC or hash for transport and flash integrity, not as a check on the schema's shape: an unknown field must be skipped, not treated as corruption.

Note that the REST API is deliberately stricter than firmware. It rejects unknown fields and out-of-range values so an operator's typo fails loudly, which means firmware should not expect to receive them in practice.

Schedules

ble_scan.schedule and every entry under reporting share one shape: the first run each day is at start_hour UTC, repeating every frequency_sec after it. For example start_hour: 6 with frequency_sec: 14400 runs at 06:00, 10:00, 14:00, 18:00 and 22:00 UTC.

FieldTypeNotes
start_hourintegerUTC hour of the first run each day (0–23)
frequency_secintegerSeconds between runs

Fields

FieldTypeNotes
enabledbooleanMaster enable/disable for device functionality. Default true
ble_scan.rssi_thresholdintegerIgnore advertisements weaker than this, in dBm (-100 to -30). Default -80
ble_scan.duration_secintegerLength of a single scan window, in seconds (1–300). Default 60
ble_scan.max_xads_to_collectintegerMax advertisements to collect per scan (1–1000). Default 1000
ble_scan.service_uuidsstring[]Restrict scans to these 16-bit service UUIDs, each 4-char hex (e.g. F5FD). Empty means no UUID filter
ble_scan.company_codesstring[]Restrict scans to these Bluetooth SIG 16-bit Company Identifiers, each 4-char hex (e.g. 6501). Empty means no company filter. Default ["6501","0165"]
ble_scan.scheduleobjectWhen scans run. frequency_sec is never less than duration_sec. Defaults: start_hour 6, frequency_sec 14400
mqtt.broker_endpointstringBroker hostname only, no scheme or port. The port is fixed device-side. Environment-specific, seeded at provisioning
mqtt.keep_alive_secintegerMQTT keepalive interval, in seconds. Default 60
mqtt.session_typestringPERSISTENT or CLEAN. Default PERSISTENT
mqtt.packet_timeout_secintegerPer-packet acknowledgement timeout, in seconds. Default 60
mqtt.last_will_enabledbooleanTop-level toggle for Last Will and Testament on all connections. Topic, payload and QoS are device-managed. Default false
antennas.blestringINTERNAL, EXTERNAL, or BOTH. Default BOTH
reporting.cellular_checkinobjectSchedule for cellular check-in. Defaults: start_hour 0, frequency_sec 14400
reporting.asset_scan_uploadobjectSchedule for uploading collected scans. Defaults: start_hour 9, frequency_sec 14400
reporting.gnssobjectSchedule for GNSS fix attempts. Defaults: start_hour 0, frequency_sec 7200
location_override.enabledbooleanfalse or absent means no override. Default false
location_override.latfloatOverride latitude (-90.0 to 90.0). Sent together with lon, and only when enabled is true
location_override.lonfloatOverride longitude (-180.0 to 180.0). Sent together with lat, and only when enabled is true

schema_version (reported-only)

Report schema_version in the config shadow's reported state alongside the applied config. It is the config schema version this firmware build implements, and it is reported-only: the cloud reads it and never writes it, so it never appears in desired.

Bump it only on a breaking change to the config's shape. Additive fields do not bump it, because tolerant readers already ignore keys they do not recognize. The current version is 1.

{
"state": {
"reported": {
"schema_version": 1,
"enabled": true,
"ble_scan": { "rssi_threshold": -80, "duration_sec": 60 }
}
}
}

location (device reports, read-only from cloud)

FieldTypeNotes
gnss.latstringLatitude from GNSS fix
gnss.lngstringLongitude from GNSS fix
gnss.altitudestringHeight above sea level (meters)
gnss.accuracystringHorizontal accuracy (meters)
gnss.hdopstringHorizontal Dilution of Precision
gnss.pdopstringPosition (3D) Dilution of Precision
gnss.svsstringSatellites visible/used
gnss.sourcestringGNSS constellation (e.g. GPS, Galileo)
gnss.statusstringFix status (e.g. 3D Fix, No Fix)
gnss.utcstringUTC timestamp from GNSS receiver
gnss.fix_durationstringSeconds to acquire fix
gnss.fix_agestringAge of fix (milliseconds)
gnss.last_successful_fixstringTimestamp of last good fix
gnss.assisted_gnss_validstringWhether A-GNSS data was current
towers.timestampstringUnix epoch seconds of tower measurement
towers.lte[].mccstringMobile Country Code
towers.lte[].mncstringMobile Network Code
towers.lte[].eutran_cidstringE-UTRAN Cell Identifier
towers.lte[].tacstringTracking Area Code
towers.lte[].rsrpstringReference Signal Received Power (dBm)
towers.lte[].rsrqstringReference Signal Received Quality (dB)
towers.lte[].rssistringReceived Signal Strength (dBm)
towers.lte[].sinrstringSignal to Interference plus Noise Ratio (dB)

On Connect

On every connect the device should GET each relevant shadow to catch any desired state changes that arrived while the device was offline — delta messages are not queued across connections.

Subscribe to update/delta before publishing the GET request. If you publish the GET first, a delta can arrive between the publish and the subscribe, and the device will miss it.

Publish an empty payload to $aws/things/{MPBID}/shadow/name/{shadowName}/get:

{}

The get/accepted response contains the full shadow document including any pending desired state.

Reference Implementation

Boot Shadow GET

Fetches the current state of a named shadow on connect. Adapt the MQTT calls to your platform's client library.

import json, time
from awscrt import mqtt
from awsiot import mqtt_connection_builder

DEVICE_MPBID = "FFFF000001"
MQTT_ENDPOINT = "mqtt.<env>.iot.digital.milwaukeetool.com" # replace <env> with dev/test/stage/prod
OPERATIONAL_CERT_PEM = "..."
OPERATIONAL_KEY_PEM = "..."
SHADOW_NAME = "config" # repeat for each shadow the device uses

conn = mqtt_connection_builder.mtls_from_bytes(
endpoint=MQTT_ENDPOINT,
cert_bytes=OPERATIONAL_CERT_PEM.encode(),
pri_key_bytes=OPERATIONAL_KEY_PEM.encode(),
client_id=DEVICE_MPBID,
clean_session=False, # persistent session: broker queues QoS 1 messages while device is offline
keep_alive_secs=30,
)
conn.connect().result()

get_response = {"data": None, "error": None}

conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/get/accepted",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=lambda topic, payload, **kw: get_response.update({"data": json.loads(payload)}),
)[0].result()

conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/get/rejected",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=lambda topic, payload, **kw: get_response.update({"error": json.loads(payload)}),
)[0].result()

conn.publish(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/get",
payload=json.dumps({}).encode(),
qos=mqtt.QoS.AT_LEAST_ONCE,
)[0].result()

for _ in range(30):
if get_response["data"] or get_response["error"]:
break
time.sleep(0.5)
# On embedded platforms, replace the polling loop above with a publish-completion
# callback: register on_get_accepted/on_get_rejected before connecting, then trigger
# the GET from the connect-complete callback. The polling approach is Python-only.

if get_response["error"]:
print(f"GET rejected: {get_response['error']}")
else:
shadow = get_response["data"]
desired = shadow.get("state", {}).get("desired", {})
reported = shadow.get("state", {}).get("reported", {})
print(f"desired: {desired}")
print(f"reported: {reported}")
# Apply any desired state that differs from reported here

Delta Handler

Subscribes to the delta topic and reports back the new state after applying a desired change. Wire this up after connect, before the boot GET above.

last_applied_version = 0 # intentionally 0: on fresh boot, process whatever delta is pending; do NOT persist this across reboots

def apply_desired_state(delta: dict) -> dict:
# Replace with your device's actual state application logic.
# Return the fields that were applied so they can be reported back.
return delta


def on_delta(topic, payload, **kwargs):
global last_applied_version
message = json.loads(payload)
version = message.get("version", 0)

if version <= last_applied_version:
print(f"Stale delta (version {version} <= {last_applied_version}), ignoring")
return

delta = message.get("state", {})
print(f"Delta received for {SHADOW_NAME}: {delta}")

# Apply delta to device — adapt this to your hardware abstraction layer
new_reported = apply_desired_state(delta)
last_applied_version = version

# Report the updated state back so AWS IoT clears the delta.
# Note: [0].result() blocks until PUBACK — on embedded platforms use your
# client's async publish and report from the completion callback instead.
conn.publish(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/update",
payload=json.dumps({"state": {"reported": new_reported}}).encode(),
qos=mqtt.QoS.AT_LEAST_ONCE,
)[0].result()

conn.subscribe(
topic=f"$aws/things/{DEVICE_MPBID}/shadow/name/{SHADOW_NAME}/update/delta",
qos=mqtt.QoS.AT_LEAST_ONCE,
callback=on_delta,
)[0].result()